Fix cross-tenant id collision clobbering memories/tasks rows - #4
Open
VamikaSinghal wants to merge 7 commits into
Open
Fix cross-tenant id collision clobbering memories/tasks rows#4VamikaSinghal wants to merge 7 commits into
VamikaSinghal wants to merge 7 commits into
Conversation
memories.id and tasks.id are plain PRIMARY KEY (not composite with user_id), and on the default local/CLI path ids are content-derived or caller-supplied with no user salt. INSERT OR REPLACE resolves conflicts purely on that primary key, so a second tenant writing the same id silently deletes and replaces the first tenant's row - exploitable in shared-DB "bucket" deployment mode (sharding.py). Guard _save_memory, _save_task, and both loops in rebuild_index_from_vault (which re-inserts from vault markdown frontmatter, also reachable in bucket mode) the same way save_capture's capture_id_override already guards captures.id: check whether the id belongs to a different user first, and only re-salt with user_id in that collision case, so the ordinary non-colliding path keeps ids unchanged.
Pins the fix in the deployment shape where it matters: bucket shard mode with shard_count=1 routes both tenants into one sqlite file. Verified load-bearing - reverting storage.py to pre-fix leaves a single memories row owned by whichever tenant wrote last, and the other tenant's search returns empty.
The cross-tenant salt guard re-derived memory_id BEFORE the tombstone check, so a memory tombstoned under its plain derived id was missed once another tenant took that id: tenant A forgets "m1", tenant B later saves its own memory as "m1", and A's next resync salts away from B's row, finds no tombstone under the salted id, and silently resurrects the forgotten memory. Check both the pre- and post-salt id. Credit: this gap was found independently on audit/prod-db-concurrency (343f0ca); ported here with a regression test that reproduces the resurrection (the memory returns as mem_2fa8523d0a70) without the fix.
ImportDiffFastAPIEndpointTests shares one process-wide store across test methods and two tests seeded the literal id "mem_python" under different user_ids. That only ever passed because the second write silently clobbered the first tenant's row - the exact bug this branch fixes. The guard correctly refuses the overwrite and re-salts, so test_import_diff_route no longer saw "mem_python". Parameterized _seed's memory_id and gave the second test a distinct one. I had previously mis-reported this failure as pre-existing/unrelated; verified it passes on pristine trace-cortex/main and fails with the guard, so it was caused by this change. Credit: same root cause and fix found on integration/final (638c965).
I originally proposed migrating memories.id to a composite PRIMARY KEY(user_id, id), mirroring entities. That is the wrong fix and would break at runtime: SQLite requires an FK's parent columns to be PK or UNIQUE, and five single-column child FKs point at memories(id) (memory_entities, memory_topics, memory_relations x2, memory_vec_map - the last independently UNIQUE). Verified empirically: OperationalError: foreign key mismatch - "memory_entities" referencing "memories" entities could go composite only because nothing FKs to entities(id). Recorded at both table definitions so the migration isn't attempted again, pointing at the write-side guards that enforce the invariant.
Re-salting ONCE was not enough. The salted id can itself be owned by a
third tenant, and INSERT OR REPLACE would then delete THEIR row - the
same bug one level down. Verified: tenant C holding
stable_id("mem_", "tenant-b:collide_mem") was silently erased when
tenant B collided with tenant A and salted into it.
Replaces the four inline single-hop guards with _tenant_unique_id(),
which chains deterministically until the id is free (bounded, then
raises rather than falling through and clobbering). The first hop is
unchanged, so ids already written stay stable, and the user_id != ?
predicate ignores the caller's own row so repeated saves stay idempotent
instead of chaining further.
Adds three tests: the third-tenant clobber (verified load-bearing), a
12-tenant pile-up on one id, and an idempotency check.
The guards stop future clobbers but cannot un-lose data already destroyed by a deployment that ran the old code, and there was no way to tell whether a given database was ever hit. A clobber only rewrote the memories/tasks row itself. Sibling rows carry their own user_id and memory_events is append-only and never rewritten, so a row whose user_id disagrees with its memory's owner is physical evidence the memory changed hands. Eight checks, each reported with its own confidence rather than summed. Detection is deliberately NOT gated on the surviving tenant count: a clobber that took over a tenant's only memory removes them from memories entirely, so that gate would report a total wipe as a harmless single-tenant database (caught by testing against a real pre-fix DB). Tenant count is instead taken across memories/captures/memory_events. Exits 1 on high-confidence evidence so it can gate a deploy check.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
memories.idandtasks.idare plainTEXT PRIMARY KEY(not composite withuser_id), and on the default local/CLI path an id is content-derived or caller-supplied with no user salt.INSERT OR REPLACEresolves conflicts purely on that key, so a second tenant writing the same id silently deletes and replaces the first tenant's row — not a duplicate error, silent cross-tenant data loss. Reachable in shared-DBbucketshard mode (sharding.py), where multiple users share one SQLite file.Four production write paths were affected, all now guarded the same way
save_capture'scapture_id_overridealready guardscaptures.id— check whether the id belongs to a different user, and only re-salt withuser_idin that collision case, so the ordinary non-colliding path keeps ids byte-identical:_save_memory_save_taskrebuild_index_from_vault(re-inserts from vault markdown frontmatter; the vault root is shared across users in bucket mode)A fifth path, the portable-memory bundle import, was audited and is already safe — its id is derived with
user_idbaked in.Anti-resurrection follow-up (
88a2e41)The guard as first written re-salted the id before the tombstone check, so a memory tombstoned under its plain derived id was missed once another tenant took that id: tenant A forgets
"m1", tenant B later saves its own memory as"m1", and A's next resync salts away from B's row, finds no tombstone under the salted id, and silently resurrects the forgotten memory. Both the pre- and post-salt id are now checked. This gap was found independently onaudit/prod-db-concurrency(343f0ca) and ported here.Second-order collision: the salt itself could clobber a third tenant (
fccfe69)Re-salting once was not enough. The salted id can itself be owned by a third tenant, so the write would delete their row instead — the same bug one level down. Verified against a real database: tenant C holding
stable_id("mem_", "tenant-b:collide_mem")was silently erased when tenant B collided with tenant A and salted into it.The four inline single-hop guards are replaced by
_tenant_unique_id(), which chains deterministically until the id is free — bounded, then raises rather than falling through and clobbering. The first hop is unchanged so ids already written stay stable, and theuser_id != ?predicate ignores the caller's own row so repeated saves stay idempotent instead of chaining further and accumulating duplicates.Forensics for databases already damaged (
7ff8aac)The guards prevent future clobbers but cannot un-lose data a deployment already destroyed, and there was no way to tell whether a given database was ever hit.
backend/app/tenant_forensics.pyis a read-only scan that answers exactly that:A clobber only rewrote the
memories/tasksrow itself — sibling rows carry their ownuser_id, andmemory_eventsis append-only and was never rewritten, so a row whoseuser_iddisagrees with its memory's owner is physical evidence the memory changed hands. Eight checks, each reported with its own confidence rather than summed into one score. Exits 1 on high-confidence evidence so it can gate a deploy check.Detection is deliberately not gated on the surviving tenant count: a clobber that took over a tenant's only memory removes them from
memoriesentirely, so that gate would report a total wipe as a harmless single-tenant database. Caught by testing the tool against a database produced by actually running the pre-fix code.Why not a composite primary key (
f57a62a)The obvious fix — migrating
memories.idtoPRIMARY KEY(user_id, id)likeentities— is wrong and breaks at runtime. SQLite requires an FK's parent columns to be a PRIMARY KEY or UNIQUE, and five single-column child FKs point atmemories(id):memory_entities,memory_topics,memory_relations(bothsource_memory_idandtarget_memory_id), andmemory_vec_map(whosememory_idis itself independentlyUNIQUE). Verified empirically:The only escapes are rewriting every child table to carry
user_id, or keeping a standalone UNIQUE index onmemories.id— which re-imposes the exact global uniqueness the composite key was meant to relax.entitiescould go composite precisely because nothing FKs toentities(id). So the invariant this schema needs is "memory/task ids are globally unique," enforced on write. Documented at both table definitions so it isn't attempted again.Test plan
backend/tests/test_sharding.py) in the deployment shape that matters:bucketmode withshard_count=1routes both tenants into one SQLite file.storage.pyto pristinemainleaves a singlememoriesrow owned by whichever tenant wrote last and returns an empty search for the other; removing the second tombstone check resurrects the forgotten memory asmem_2fa8523d0a70.trace-cortex/main:test_rerank_eval(missingmodel2vecdependency) andtest_macos_ui_quality_contract.One failure was caused by this change, and is fixed here (
90e9ad7)test_import_diff.py::test_import_diff_routewas initially mis-reported as pre-existing. It is not — it passes on pristinemain(30/30) and fails with the guard.ImportDiffFastAPIEndpointTestsshares one process-wide store across test methods, and two tests seeded the literal id"mem_python"under two differentuser_ids; unittest runs methods alphabetically, so the second seed clobbered the first. That test only ever passed because of the bug this PR fixes._seed'smemory_idis now parameterized and the second test uses a distinct id. Same root cause and fix asintegration/final(638c965).Merge note: this should land first
audit/prod-db-concurrencyalso carries these guards plus its own concurrency work, but it is not a peer of this PR and cannot merge tomainyet — it is built on the entire taste-exclude feature, which does not exist inmain.audit/prod-db-concurrencymaintaste_excludedrefsstorage.py, 2 indatabase.pymaintodaySo: land this, then rebase
audit/prod-db-concurrencyonto updatedmain(~10 min; conflicts are limited tostorage.pyanddatabase.py, and since both branches now carry identical anti-resurrection code that region resolves by taking either side — its guard commits become no-ops). The alternative leaves a live cross-tenant data-loss bug inmainwhile this sits blocked behind an unrelated feature.The anti-resurrection fix has also been ported to the other branches that carried the guards without it (
integration/final,audit/prod-security,audit/prod-reliability,audit/prod-backend-arch), so no merge order can reintroduce the resurrection bug.CI
The 3 failing checks are pre-existing —
mainfails the identical 3 jobs (Distribution site,Python checks,Security scan). The Python failure isassertIn("hasAppleSignInEntitlement", source)in the macOS UI contract test; the distribution failure is a missing release DMG. This PR introduces no new failures.